RemoveUnusedLabels: preserve comments and Kotlin-referenced labels - #966
Draft
martinfrancois wants to merge 3 commits into
Draft
RemoveUnusedLabels: preserve comments and Kotlin-referenced labels#966martinfrancois wants to merge 3 commits into
martinfrancois wants to merge 3 commits into
Conversation
Removing a label replaced the labeled statement's prefix with the label's prefix, so any comment sitting between the label and its statement was silently dropped, which a recipe must never do to source comments. The comments of the label prefix, of the space after the colon, and of the statement's own prefix are now concatenated in source order, so comments before the label, after the colon, and before the statement all survive. The used-label check only looked at J.Break and J.Continue, so a Kotlin label referenced through `return@label` or `this@label` counted as unused and was deleted, leaving source that no longer compiles. The scan now also covers K.Return and K.This, guarded by ReflectionUtils.isClassAvailable in the manner of KotlinFileChecker so the recipe keeps working on classpaths without rewrite-kotlin, where an unguarded instanceof would raise NoClassDefFoundError. The Kotlin check matches on label name alone. When a nested lambda reuses an enclosing label's name, the enclosing label is now kept even though only the inner one is referenced; that is a deliberate trade against modelling Kotlin label scoping. Java behaviour is unchanged. The recipe description and its recipes.csv row are updated to match.
4 tasks
martinfrancois
marked this pull request as draft
August 17, 2026 08:08
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Suggested review order: 16 of 52 (Score: 7)
Review first: openrewrite/rewrite-testing-frameworks#1093
What's changed?
Two things change, one for Kotlin and one for comments.
First,
RemoveUnusedLabelsnow counts a Kotlin labeled return,return@nameparsed asK.Return, and a Kotlin qualifiedthis,this@nameparsed asK.This, as references to the labelname. On main the reference search looks only atJ.BreakandJ.Continue, so it sees neither form and removes a label that is in use.Second, the recipe now keeps the comments belonging to a label it removes. On main the visitor returns
l.getStatement().withPrefix(l.getPrefix()), overwriting the labeled statement's prefix instead of merging the two, so a comment sitting in that prefix is dropped.The new prefix keeps the whitespace of the label's prefix, so indentation does not change, and its comments are the concatenation, in source order, of the label's prefix, the space between the label name and the colon (a
Spaceof its own in the LST that can hold comments, as inlabel /* an odd place */ : while (true) { ... }), and the statement's own prefix, which holds anything written after the colon.The Kotlin check runs only when
rewrite-kotlinis on the runtime classpath, which is not guaranteed, since it is declared as aprovideddependency. It is guarded by aReflectionUtils.isClassAvailable("org.openrewrite.kotlin.tree.K")constant, the same mechanism on the same class name that this repository'sKotlinFileCheckeruses.Because the recipe now recognises two more kinds of reference, its
descriptionchanges to "Remove labels that are not referenced by anybreakorcontinuestatement or by a Kotlin labeledreturnorthisexpression", and theRemoveUnusedLabelsrow of the checked-in generated filesrc/main/resources/META-INF/rewrite/recipes.csvcarries the same text.What's your motivation?
Recipe:
org.openrewrite.staticanalysis.RemoveUnusedLabels.Case 1: Kotlin labeled return
Before
items.forEach callback@{ if (stop) return@callback }Actual after the recipe
items.forEach { if (stop) return@callback }Expected after the recipe
(unchanged)The generated Kotlin does not compile because
return@callbackrefers to a label that the recipe deleted.RemoveUnusedLabelsis part oforg.openrewrite.staticanalysis.CodeCleanup, so applying code cleanup to Kotlin can break the build.Case 2: comment attached to a Java label
Before
Actual after the recipe
Expected after the recipe
The Java output compiles, but it loses the comment attached to the label. Both defects reproduce on v2.39.0, v2.40.0, and current main, which carry the same implementation.
Confirmed real-world execution
MainActivity.ktat75a504b6.org.openrewrite.recipe:rewrite-static-analysis:2.41.0.The released recipe removes the explicit
callback@label and leaves tworeturn@callbackstatements. The generated Kotlin does not compile because those returns refer to a label that no longer exists.Anything in particular you'd like reviewers to focus on?
No existing test expectation changed. The six tests already in
RemoveUnusedLabelsTestkeep their input and their expected output.Two limits, both describing the behaviour with this change applied:
return@xor athis@xinside the labeled statement uses the same name as an unused outer label, the recipe keeps that outer label. The check is conservative: it can keep a safe-to-remove label, but it does not remove a referenced label.gotois still removed:Cs.GotoStatementholds its target as a plainExpression, not as a reference to a label, so the search cannot recognise the use. Main removes such a label too, so this change neither introduces that behaviour nor fixes it.Have you considered any alternatives or workarounds?
One option is to skip Kotlin files altogether, with
Preconditions.check(Preconditions.not(new KotlinFileChecker<>()), ...). This repository's ownCLAUDE.mdnames that idiom as the way to exclude a language: usePreconditions.check()to exclude specific file types, Kotlin files among them, when a recipe is language specific. That would be a smaller change, roughly 40 lines against the 331 added here, but the recipe would then never remove any Kotlin label at all, including the unused ones it removes correctly today and still removes with this change. Tell me if you prefer it.Any additional context
This change adds 11 tests to
RemoveUnusedLabelsTest, taking it from 6 tests to 17. It deletes or renames no existing test. Without the code change in this pull request, 8 of the new tests fail. They cover these cases:return@nameandthis@namedo/whileloops,switchstatements, and expression statementsThe test methods name each exact case in the changed test file.
The other 3 added tests are controls: two show that an unused Kotlin label is still removed, one on a
whileloop and one on a lambda, and one shows that a Java label in use keeps both the label and its comment.This change was prepared with AI assistance (Claude Code). I reviewed the code, the tests and this description.
Checklist
./gradlew buildlocally, and committed any resulting changes torecipes.csv